Skip to content

[고객지원 챗봇 만들기] 이창희 제출합니다.#7

Open
chxghee wants to merge 20 commits into
cho-log:mainfrom
chxghee:chxghee
Open

[고객지원 챗봇 만들기] 이창희 제출합니다.#7
chxghee wants to merge 20 commits into
cho-log:mainfrom
chxghee:chxghee

Conversation

@chxghee
Copy link
Copy Markdown

@chxghee chxghee commented May 23, 2026

안녕하세요! 챗봇 구현 내용 PR 제출합니다 🙋‍♂️

구현 내용

  • Spring AI + SimpleVectorStore 기반 RAG 챗봇 구현
  • FAQ / 정책 문서 / 챗로그 3개 레이어로 문서를 분리하여 관리
  • 청킹 전략: 마크다운 헤더(##) 기준 청킹 + 상위 헤더(#) 접두어로 카테고리 맥락 포함
  • Top-k 설정 : 레이어별 top-k 독립 설정으로 정책 문서가 챗로그에 밀리지 않도록 보장
    • FAQ: 4 / 정책 문서: 4 / 챗로그: 3 = 총 11개
  • 평가 스크립트 수정: 여러 축의 KPI 평가 기준을 설계하고, 항목별 루브릭 평가 진행

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a RAG-based chatbot implementation using Spring AI, incorporating a multi-layered document retrieval system that searches across FAQ, policy, and chatlog data. It also includes a comprehensive evaluation suite to measure chatbot quality through specific KPIs and parallel processing. The review feedback suggests enhancing the implementation by using dependency injection for the ObjectMapper, adding defensive null checks for AI response metadata, and implementing input validation for the chatbot request DTO.

private static final String LAYER_CHATLOG = "chatlog";
private static final String ACCURACY_CORRECT = "correct";

private final ObjectMapper objectMapper = new ObjectMapper();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ObjectMapper를 클래스 내부에서 직접 생성하기보다 Spring의 의존성 주입(DI)을 통해 주입받아 사용하는 것이 좋습니다. 이를 통해 애플리케이션 전반의 JSON 설정 일관성을 유지할 수 있으며, 테스트 시 모킹(Mocking)이 용이해집니다.

    private final ObjectMapper objectMapper;

    public ChatlogParser(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

Comment on lines +14 to +20
Usage usage = response.getMetadata().getUsage();
return new ChatbotResult(
response.getResult().getOutput().getText(),
usage.getPromptTokens(),
usage.getCompletionTokens(),
usage.getTotalTokens()
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

AI 모델의 응답 메타데이터나 토큰 사용량(Usage) 정보는 상황에 따라 null일 수 있습니다. 또한 response.getResult() 등의 호출 결과에 대해서도 NPE(NullPointerException)가 발생하지 않도록 방어적인 코드를 추가하는 것이 안전합니다.

        Usage usage = (response.getMetadata() != null) ? response.getMetadata().getUsage() : null;
        String answer = (response.getResult() != null && response.getResult().getOutput() != null)
                ? response.getResult().getOutput().getText()
                : "";

        return new ChatbotResult(
                answer,
                usage != null ? usage.getPromptTokens() : 0,
                usage != null ? usage.getCompletionTokens() : 0,
                usage != null ? usage.getTotalTokens() : 0
        );

package com.cholog.bootcamp.chatbot.presentation.dto;

public record ChatbotRequest(
String question
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

챗봇 요청의 핵심인 question 필드에 대한 유효성 검사가 누락되었습니다. 빈 문자열이나 null이 전달될 경우 RAG 검색 과정에서 예외가 발생할 수 있으므로, @NotBlank와 같은 제약 조건을 추가하여 입력을 검증하는 것이 좋습니다. (참고: 이를 위해 spring-boot-starter-validation 의존성 추가가 필요할 수 있습니다.)

Copy link
Copy Markdown
Contributor

@jaeyeonling jaeyeonling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생 많으셨습니다 : )


@Configuration
public class AiConfig {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프롬프트 너무 좋네요 👍
다만 Java 상수로 관리되는 부분은 아래와 같은 문제가 있습니다.

  • 운영자가 톤을 바꾸려면 컴파일·빌드·배포가 필요
  • A/B 비교(v1/v2)가 별도 클래스/변수 추가로만 가능
  • diff에서는 String 한 줄이 길게 보일 뿐, 어떤 의도가 바뀌었는지 안 보임

수정 한 곳, 영향 한 곳이라는 원칙을 지키도록 src/main/resources/prompts/faq-system.st로 옮기고 @Value("classpath:prompts/faq-system.st")로 주입하는 구조로 개선해보면 어떨까요?

추가적으로 LLM 호출 직전에 advisor 하나를 붙여서 (CallAroundAdvisor 구현체) 시스템 메시지 + 유저 메시지 전체를 log로 한 번 찍어보면, Spring AI Advisor가 본인이 작성한 prompt 외에 자동으로 무언가 prepend하지 않는지 확인할 수 있습니다!

private final VectorStore vectorStore;

public ChatbotResult chat(String question) {
String context = searchRelevantDocuments(question);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chat() 한 메서드가 (1) 검색 (2) LLM 호출 (3) 응답 송출을 모두 합니다.
결정(LLM 호출 결과)과 효과(사용자 송출)가 묶여 있습니다.

결과는:

  • LLM이 만든 답이 신뢰도와 무관하게 즉시 사용자에게 전달
  • 단위 테스트로 "검색 결과가 비었을 때 운영자에게 escalate한다"를 검증하려면 mock 5-7개 필요
  • 같은 입력 → 같은 출력 보장 불가

작게는 ChatbotResult chat(...) 안에서 결정 결과를 반환만 하고, 송출은 caller가 분기하는 형태로 분리해볼 수 있습니다.

예시)

// service: 결정만
PendingAnswer pending = composeAnswer(question);

// caller(dispatcher): 효과 분기
if (pending.confidence() < 0.7) {
  escalateToOperator(pending);
} else {
  sendToUser(pending);
}

그러면 신뢰도가 낮을 때 운영자 검토로 보내거나, 정책 답변일 때 별도 검토 큐로 보내는 식의 확장이 가능해집니다.

ChatResponse aiResponse = chatClient.prompt()
.user(userMessage)
.call()
.chatResponse();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chatClient.prompt(...).call().chatResponse()에서

  • OpenAI rate limit / network 일시 장애
  • usage가 null인 경우
  • 응답에 result가 비어있는 경우

와 같은 문제가 있을 때 그대로 사용자에게 500으로 갈 가능성이 있습니다.

핵심은 에러도 LLM이 알 수 있는 형태로 만들기입니다.

try {
    return chatClient.prompt(...).call().chatResponse();
} catch (Exception e) {
    // 다음 LLM 호출 컨텍스트에 에러를 주입하면
    // LLM이 "검색 도구가 실패했으니 키워드 검색으로 폴백하자" 같은 결정 가능
    conv.append(new Event("tool_error", Map.of("hint", "검색 서버 일시 장애")));
}

또 동일 도구가 연속 3회 실패하면 자동 escalate하는 counter도 같이 두면, LLM이 같은 검색을 무한 반복하면서 토큰 비용을 폭증시키는 사고를 막을 수 있습니다.


private String searchRelevantDocuments(String question) {
List<Document> docs = new java.util.ArrayList<>();
docs.addAll(searchByLayer(question, "faq", FAQ_TOP_K));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

layer별 topK를 4/4/3으로 의도적으로 분리하셨네요 👍
정책 문서가 챗로그 노이즈에 밀리지 않게 하려는 결정이 코드에 또렷이 보입니다.

추가로 한 번 생각해볼 만한 것 남겨둘게요.

  • 위치 효과 — 11개 청크를 단순 concat할 때 LLM은 입력의 시작과 끝을 더 강하게 참조합니다 (중간이 묻힘). 가장 중요한(=정책) 문서를 어느 자리에 놓는 게 좋을까요? 지금은 faq → policy → chatlog 순으로 합쳐지는데, policy가 마지막에 오면 답변 영향력이 커질 수 있습니다.
  • 개인정보 처리 — 사용자가 "내 주문 #12345"라고 말했을 때 그 문자열이 그대로 LLM context에 들어갑니다. 다음 사용자의 컨텍스트에 섞이지 않도록 마스킹할 자리는 어디일까요?

String title = extractTitle(text);

String[] sections = text.split("(?m)^(?=" + heading + " )");
return Arrays.stream(sections)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

청크 형태가 title + "\n" + section 인데, 실제로 LLM 컨텍스트에 들어갈 때 이게 어떻게 보이는지 한 번 확인해보세요.

예를 들어:

# 환불 정책
### 환불 가능 기간
결제일로부터 7일 이내...

이 형태가 LLM에게 "환불 정책 문서의 환불 가능 기간 섹션"으로 명확하게 전달이 될지, 그리고 이런 형태가 의도가 맞는지 고민해보면 좋겠습니다.
청킹 전략과 LLM 입력 형태는 한 묶음으로 결정해야 하는 부분인 점 참고해주세요~

}

@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SimpleVectorStore는 인메모리라서 아래와 같은 문제가 존재합니다.

  • 서버 재시작 = 임베딩 다시 호출 (비용 발생)
  • 멀티 인스턴스 운영 시 인스턴스마다 따로 임베딩 (=같은 비용 N배)
  • 운영 중에 FAQ 추가 → restart해야 반영

VectorStore 영속화를 한다면 어떻게 할지, 이어서 Conversation 영속화는 어떻게 할 수 있을지도 같이 고민해보면 좋겠습니다!

Comment thread mission/wall-report.md

→ 실제로 고객 개인 정보 거절 규칙을 추가했더니, 탈퇴 방법·비밀번호 변경처럼 일반적인 절차 질문에도 거절하는 문제가 발생

→ 결국 시스템 프롬프트 수정은 근본적인 원인을 해결하지 못한다고 느낌
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"근본적인 원인을 해결하지 못한다고 느낌"
시스템 프롬프트에 분기 규칙을 누적하면 결국 LLM이 모든 결정을 떠안는 구조가 되고, 새 규칙은 항상 기존 규칙과 같은 평면에서 경쟁하게 됩니다 (충돌은 필연).

이미 시도한 *layer별 topK 분리 (FAQ 4 / Policy 4 / Chatlog 3)*에서 힌트를 얻을 수 있는데요.
"챗로그가 정책을 가리지 않게 하라" 같은 규칙을 프롬프트에 적는 대신, 검색 단계에서 보장 한 것과 동일하게 개인정보 거절 케이스도 같은 식으로 풀어볼 수 있습니다.

  • 입력에 주문번호/카드번호 패턴이 있으면 → 코드가 "민감정보 포함" 플래그를 세움
  • 플래그가 있으면 → 다른 system prompt 변형 사용 (또는 별도 답변 분기)
  • LLM은 "민감하면 거절"이라는 일반 규칙만 알면 됨, 어떤 게 민감한지는 코드가 판단

"탈퇴 방법 안내""내 주문번호 알려줘" 의 차이를 LLM의 자유 텍스트 분기에 맡기지 않는 형태가 됩니다.

Comment thread mission/wall-report.md
- 추가로 힌트를 보니

`Spring Boot Test + MockMvc — API 동작 자체를 테스트 코드로 검증하는 방법도 조사해보세요`
라고 적혀 있었는데, 이건 어떤걸 해보길 의도하신건지 궁금합니다!
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1) 자기참조 문제 (KPI를 본인이 정의 -> 본인 챗봇 평가)

이미 창희님이 진행하신 *"사람이 직접 샘플을 보고 평가"*가 답이 될 수 있을 것 같은데요. 추가로

  1. Judge 다양화 - 같은 KPI를 GPT-4o vs Claude로 각각 채점해 비교. 두 judge가 동시에 "개선"이라 말하면 신뢰도 상승
  2. 회귀 비교 - KPI는 "절대값"보다 "어제와 오늘이 비교 가능한지" 가 핵심입니다. 평가 기준을 한 번 고정한 뒤로는 프롬프트 변경 -> 점수 변화 가 보이고, 그 자체로 가치가 큽니다.
  3. 자동 contradiction 검출 - 답변에 들어간 수치/조건이 검색된 청크와 충돌하는지 별도 LLM이 한 번 더 검증. KPI 채점과 다른 축의 검증입니다.
  4. 외부 black-box 테스트셋 - 본인이 작성하지 않은 사람이 만든 질문 30-50개로 따로 측정. KPI는 본인이 만들었어도 입력은 외부에서 오면 자기참조가 약해집니다.

그래서 "평가 점수가 뛴다" 보다 "여러 축이 같은 방향을 가리킨다" 가 더 신뢰할 만한 신호입니다.

2) MockMvc 의도

"챗봇의 답변 품질""API 자체의 동작" 은 다른 축입니다. 예시:

  • 빈 question -> 400 응답?
  • 너무 긴 question (예: 10,000자) -> 413? 200? 어떤 정책?
  • OpenAI rate limit 발생 -> 사용자에게 어떤 status / 어떤 메시지?
  • tokenUsage 필드는 항상 응답에 포함되는가? (null이 가능한가?)

KPI 평가는 "LLM이 적절한 답을 했는가", MockMvc는 "API 계약(input -> output)이 깨졌는가" 를 봅니다.
후자는 LLM mock으로 충분하고, 빠르고, 회귀를 자동으로 잡습니다. "평가""테스트" 를 분리하라는 의도였어요.
둘은 자주 섞이지만 각각 다른 신호를 줍니다.

Comment thread mission/wall-report.md

- 검색된 문서 중 챗로그에 "계좌이체 지원합니다"라는 문장이 있었고, LLM이 "계좌이체"와 "가상계좌"를 같은 개념으로 혼동하여 지원된다고 답변한 것으로 보임

(해당 챗 로그가 가장 유사도가 높게 검색되었음
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 사례는 RAG의 구조적 한계를 정확히 보여주는 좋은 회고 자료입니다. 청킹, topK, 시스템 프롬프트 어느 쪽으로도 깔끔히 안 풀리는 영역이에요.

문제의 본질:

  • 임베딩 유사도는 단어 표면의 유사성을 잡지, 의미적 차이("계좌이체 가능 ≠ 가상계좌 불가")를 모릅니다
  • LLM은 검색 결과에 "계좌이체 지원"이 있으면 가상계좌도 같은 것 으로 추론할 자유가 있습니다 (특히 "있다" 쪽으로 편향)

풀어볼 만한 방향:

  1. 명시적 부정문 보강 - 정책 문서에 "가상계좌는 지원하지 않습니다" 라는 문장이 있으면 그게 임베딩에 잡힙니다. "없음" 사실을 적극적으로 문서화하기.
  2. 인용 강제 - system prompt에 "답변에 사용한 정책 청크를 그대로 인용하라" 를 추가하면 LLM이 "근거 문서에 가상계좌 가능이라 적혀있지 않음 -> 답할 수 없음" 으로 빠지기 쉬워집니다.
  3. 2-step 검증 - 답변 생성 후, 별도 LLM 호출로 "이 답변이 근거 청크와 모순되지 않는가" 를 묻기 (느리고 비용이 올라가지만 hard 질문에서만 발동시키면 됨)
  4. Hybrid 검색 - 임베딩 + BM25 (키워드 매칭) 동시 검색. "가상계좌"라는 정확한 단어가 들어간 문서에 가중치.

다만 가장 중요한 회고는 "단어가 비슷한데 의미가 반대" 인 케이스는 RAG에서 1순위 실패 패턴이라는 인식 자체입니다. 어떤 방법을 쓰든 0이 되진 않아요.

Comment thread mission/wall-report.md

- 줄임말 등 을 사용하면, 관련 문서 검색에 있어 유사도가 낮게 측정되어,
분명 해당 내용이 있는 문서임에도 해당 문서를 검색하지 못하는 상황이 왕왕 있었다.
- llm을 한 번 더 호출하는 만큼 응답 시간의 지연이 발생할수 있고, 토큰의 사용량이 높아질 텐데 이 트레이드 오프를 고려한 기준을 세우는 것이 중요할 것 같다.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Query rewriting은 좋은 방향이고, 적어주신 트레이드오프(지연, 토큰)도 정확합니다. 한 단계 더 들어가면:

모든 질문에 rewriting을 할 것인가?

"환불 정책 알려줘" 같은 정상 질문엔 rewriting이 필요 없고 오히려 노이즈가 됩니다.
목표로 세우신 "줄임말 등을 사용하면 유사도가 낮게" 케이스만 골라낼 수 있다면 비용이 크게 줄어들 것 같아요

  • 첫 검색 결과의 최고 유사도가 임계값 미만일 때만 rewriting (검색이 자신 없을 때만)
  • 질문 길이가 일정 미만(키워드형) 일 때만
  • 일단 답변을 생성하되, LLM이 "근거 부족" 을 선언하면 rewriting 후 재검색

와 같은 기준으로 분기를 해볼 수 있을 것 같아요.

핵심은 "한 번에 다 잘하려 하지 말고, LLM이 자기 결과를 보고 다음 결정" 하는 것인데요.
결국 "분기와 결정의 자리"에 대한 얘기였는데, query rewriting을 어디에 둘지도 같은 축의 결정이에요.

추가로 rewriting된 query는 사용자에게 노출해도 문제 없을 것 같아요.
오히려 "이렇게 이해했어요. 맞나요?" 같은 형태로 사용자가 수정할 수 있는 것이 더 좋은 경험이 될 수 있을 것 같습니다.
(이미 많은 LLM 서비스에서 시도하고 있고요!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants